Java 26-Day Course - Day 23: JUnit 5 Testing

Day 23: JUnit 5 Testing

JUnit 5 is Java’s standard testing framework. Test code acts as an inspector that automatically verifies your program works correctly. Instead of manually checking every time, a single ./gradlew test command validates all functionality.

JUnit 5 Basic Tests

The basic structure of test classes and methods.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

// Class under test
class Calculator {
    int add(int a, int b) { return a + b; }
    int subtract(int a, int b) { return a - b; }
    int multiply(int a, int b) { return a * b; }
    int divide(int a, int b) {
        if (b == 0) throw new ArithmeticException("Cannot divide by zero");
        return a / b;
    }
}

// Test class
@DisplayName("Calculator Tests")
class CalculatorTest {
    private Calculator calc;

    @BeforeEach // Runs before each test method
    void setUp() {
        calc = new Calculator();
    }

    @Test
    @DisplayName("Addition of two numbers works correctly")
    void testAdd() {
        assertEquals(5, calc.add(2, 3));
        assertEquals(0, calc.add(-1, 1));
        assertEquals(-5, calc.add(-2, -3));
    }

    @Test
    @DisplayName("Subtraction of two numbers works correctly")
    void testSubtract() {
        assertEquals(1, calc.subtract(3, 2));
        assertEquals(-2, calc.subtract(-1, 1));
    }

    @Test
    @DisplayName("Division by zero throws ArithmeticException")
    void testDivideByZero() {
        ArithmeticException exception = assertThrows(
            ArithmeticException.class,
            () -> calc.divide(10, 0)
        );
        assertEquals("Cannot divide by zero", exception.getMessage());
    }

    @Test
    @Disabled("Feature not yet implemented")
    void testSquareRoot() {
        // TODO: Write test after adding square root feature
    }

    @AfterEach // Runs after each test method
    void tearDown() {
        calc = null;
    }
}

Various Assertion Methods

Core methods used for test verification.

import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class AssertionExamples {

    @Test
    void basicAssertions() {
        // Equality comparison
        assertEquals(4, 2 + 2, "2+2 should be 4");
        assertNotEquals(5, 2 + 2);

        // Boolean verification
        assertTrue(10 > 5, "10 should be greater than 5");
        assertFalse("".length() > 0);

        // Null verification
        String name = "Java";
        String nullStr = null;
        assertNotNull(name);
        assertNull(nullStr);

        // Same object (reference comparison)
        String a = "hello";
        String b = a;
        assertSame(a, b);
    }

    @Test
    void collectionAssertions() {
        List<String> fruits = List.of("Apple", "Banana", "Grape");

        // Size check
        assertEquals(3, fruits.size());

        // Contains check
        assertTrue(fruits.contains("Apple"));

        // Iterable element check (order-sensitive)
        assertIterableEquals(
            List.of("Apple", "Banana", "Grape"),
            fruits
        );
    }

    @Test
    void exceptionAssertions() {
        // Verify exception is thrown
        assertThrows(NumberFormatException.class, () -> {
            Integer.parseInt("abc");
        });

        // Verify no exception is thrown
        assertDoesNotThrow(() -> {
            Integer.parseInt("123");
        });
    }

    @Test
    void groupedAssertions() {
        String name = "Alice";
        int age = 25;

        // assertAll: runs all assertions at once (remaining continue even if one fails)
        assertAll("User info verification",
            () -> assertNotNull(name, "Name should not be null"),
            () -> assertTrue(name.length() > 0, "Name should not be empty"),
            () -> assertTrue(age > 0, "Age should be positive"),
            () -> assertTrue(age < 150, "Age should be less than 150")
        );
    }

    @Test
    void timeoutAssertions() {
        // Verify execution within time limit
        assertTimeout(Duration.ofSeconds(2), () -> {
            Thread.sleep(500); // 0.5s -> passes as it's within 2s
        });
    }
}

Parameterized Tests

Run the same test repeatedly with various input values.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.*;

class StringValidator {
    boolean isValidEmail(String email) {
        return email != null && email.matches("[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
    }

    boolean isStrongPassword(String password) {
        if (password == null || password.length() < 8) return false;
        boolean hasUpper = password.chars().anyMatch(Character::isUpperCase);
        boolean hasLower = password.chars().anyMatch(Character::isLowerCase);
        boolean hasDigit = password.chars().anyMatch(Character::isDigit);
        return hasUpper && hasLower && hasDigit;
    }
}

class StringValidatorTest {
    private final StringValidator validator = new StringValidator();

    // @ValueSource: single value array
    @ParameterizedTest(name = "Valid email: {0}")
    @ValueSource(strings = {
        "user@example.com",
        "test.name@company.co.kr",
        "admin+tag@gmail.com"
    })
    void validEmails(String email) {
        assertTrue(validator.isValidEmail(email));
    }

    @ParameterizedTest(name = "Invalid email: {0}")
    @ValueSource(strings = {"", "invalid", "@no-user.com", "no-domain@"})
    @NullSource
    void invalidEmails(String email) {
        assertFalse(validator.isValidEmail(email));
    }

    // @CsvSource: multiple argument combinations
    @ParameterizedTest(name = "{0} + {1} = {2}")
    @CsvSource({
        "1, 2, 3",
        "0, 0, 0",
        "-1, 1, 0",
        "100, 200, 300"
    })
    void testAddition(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }

    // @MethodSource: arguments from a method
    @ParameterizedTest(name = "Strong password: {0} -> {1}")
    @MethodSource("passwordProvider")
    void testStrongPassword(String password, boolean expected) {
        assertEquals(expected, validator.isStrongPassword(password));
    }

    static Stream<Arguments> passwordProvider() {
        return Stream.of(
            Arguments.of("Abcdef1!", true),
            Arguments.of("StrongP4ss", true),
            Arguments.of("weak", false),
            Arguments.of("nouppercase1", false),
            Arguments.of("NOLOWERCASE1", false),
            Arguments.of("NoDigitsHere", false),
            Arguments.of(null, false)
        );
    }

    // @EnumSource: test with enum values
    @ParameterizedTest
    @EnumSource(java.time.Month.class)
    void allMonthsAreValid(java.time.Month month) {
        assertTrue(month.getValue() >= 1 && month.getValue() <= 12);
    }
}

Test Organization (Nested, Lifecycle)

Logically group tests and manage their lifecycle.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

import java.util.ArrayList;
import java.util.List;

@DisplayName("Shopping Cart Tests")
class ShoppingCartTest {
    private List<String> cart;

    @BeforeAll  // Runs once before all tests
    static void initAll() {
        System.out.println("Tests starting");
    }

    @BeforeEach
    void setUp() {
        cart = new ArrayList<>();
    }

    @Nested
    @DisplayName("With an empty cart")
    class EmptyCart {
        @Test
        @DisplayName("Item count is 0")
        void isEmpty() {
            assertTrue(cart.isEmpty());
            assertEquals(0, cart.size());
        }

        @Test
        @DisplayName("After adding an item, size becomes 1")
        void addItem() {
            cart.add("Laptop");
            assertEquals(1, cart.size());
            assertTrue(cart.contains("Laptop"));
        }
    }

    @Nested
    @DisplayName("With items in the cart")
    class NonEmptyCart {
        @BeforeEach
        void addItems() {
            cart.add("Laptop");
            cart.add("Mouse");
            cart.add("Keyboard");
        }

        @Test
        @DisplayName("Item count is 3")
        void hasThreeItems() {
            assertEquals(3, cart.size());
        }

        @Test
        @DisplayName("Can delete a specific item")
        void removeItem() {
            cart.remove("Mouse");
            assertEquals(2, cart.size());
            assertFalse(cart.contains("Mouse"));
        }

        @Test
        @DisplayName("Clear all works")
        void clearCart() {
            cart.clear();
            assertTrue(cart.isEmpty());
        }

        @Test
        @DisplayName("Duplicate items can be added")
        void duplicateItem() {
            cart.add("Laptop");
            assertEquals(4, cart.size());
            assertEquals(2, cart.stream().filter("Laptop"::equals).count());
        }
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("Tests complete");
    }
}

Today’s Exercises

  1. String Utility Tests: Create a StringUtils class with reverse(), isPalindrome(), and countVowels() methods, and write JUnit 5 tests for each. Include normal cases, edge cases, and null/empty string cases.

  2. Parameterized Tests: Create a phone number validation method and write parameterized tests using @CsvSource and @MethodSource with sets of valid and invalid numbers.

  3. TDD Practice: Implement a Stack<T> class using the RED-GREEN-REFACTOR cycle. First write failing tests, then implement the minimum to pass them, then refactor. Test push, pop, peek, isEmpty, size, and underflow exceptions.

Was this article helpful?